'use strict'

const CiPlugin = require('../../dd-trace/src/plugins/ci_plugin')
const { storage } = require('../../datadog-core')
const { getEnvironmentVariable } = require('../../dd-trace/src/config-helper')

const {
  TEST_STATUS,
  JEST_TEST_RUNNER,
  finishAllTraceSpans,
  getTestSuiteCommonTags,
  addIntelligentTestRunnerSpanTags,
  TEST_PARAMETERS,
  TEST_COMMAND,
  TEST_FRAMEWORK_VERSION,
  TEST_SOURCE_START,
  TEST_ITR_UNSKIPPABLE,
  TEST_ITR_FORCED_RUN,
  TEST_CODE_OWNERS,
  ITR_CORRELATION_ID,
  TEST_SOURCE_FILE,
  TEST_IS_NEW,
  TEST_IS_RETRY,
  TEST_EARLY_FLAKE_ENABLED,
  TEST_EARLY_FLAKE_ABORT_REASON,
  JEST_DISPLAY_NAME,
  TEST_IS_RUM_ACTIVE,
  TEST_BROWSER_DRIVER,
  getFormattedError,
  TEST_RETRY_REASON,
  TEST_MANAGEMENT_ENABLED,
  TEST_MANAGEMENT_IS_QUARANTINED,
  TEST_MANAGEMENT_IS_DISABLED,
  TEST_MANAGEMENT_IS_ATTEMPT_TO_FIX,
  TEST_MANAGEMENT_ATTEMPT_TO_FIX_PASSED,
  TEST_HAS_FAILED_ALL_RETRIES,
  TEST_RETRY_REASON_TYPES,
  TEST_IS_MODIFIED
} = require('../../dd-trace/src/plugins/util/test')
const { COMPONENT } = require('../../dd-trace/src/constants')
const id = require('../../dd-trace/src/id')
const {
  TELEMETRY_EVENT_CREATED,
  TELEMETRY_EVENT_FINISHED,
  TELEMETRY_CODE_COVERAGE_STARTED,
  TELEMETRY_CODE_COVERAGE_FINISHED,
  TELEMETRY_ITR_FORCED_TO_RUN,
  TELEMETRY_CODE_COVERAGE_EMPTY,
  TELEMETRY_ITR_UNSKIPPABLE,
  TELEMETRY_CODE_COVERAGE_NUM_FILES,
  TELEMETRY_TEST_SESSION
} = require('../../dd-trace/src/ci-visibility/telemetry')
const log = require('../../dd-trace/src/log')

const isJestWorker = !!getEnvironmentVariable('JEST_WORKER_ID')

// https://github.com/facebook/jest/blob/d6ad15b0f88a05816c2fe034dd6900d28315d570/packages/jest-worker/src/types.ts#L38
const CHILD_MESSAGE_END = 2

function withTimeout (promise, timeoutMs) {
  return new Promise(resolve => {
    // Set a timeout to resolve after 1s
    setTimeout(resolve, timeoutMs)

    // Also resolve if the original promise resolves
    promise.then(resolve)
  })
}

class JestPlugin extends CiPlugin {
  static id = 'jest'

  // The lists are the same for every test suite, so we can cache them
  getUnskippableSuites (unskippableSuitesList) {
    if (!this.unskippableSuites) {
      this.unskippableSuites = JSON.parse(unskippableSuitesList)
    }
    return this.unskippableSuites
  }

  getForcedToRunSuites (forcedToRunSuitesList) {
    if (!this.forcedToRunSuites) {
      this.forcedToRunSuites = JSON.parse(forcedToRunSuitesList)
    }
    return this.forcedToRunSuites
  }

  constructor (...args) {
    super(...args)

    if (isJestWorker) {
      // Used to handle the end of a jest worker to be able to flush
      const handler = ([message]) => {
        if (message === CHILD_MESSAGE_END) {
          // testSuiteSpan is not defined for older versions of jest, where jest-jasmine2 is still used
          if (this.testSuiteSpan) {
            this.testSuiteSpan.finish()
            finishAllTraceSpans(this.testSuiteSpan)
          }
          this.tracer._exporter.flush()
          process.removeListener('message', handler)
        }
      }
      process.on('message', handler)
    }
    this.testSuiteSpanPerTestSuiteAbsolutePath = new Map()

    this.addSub('ci:jest:session:finish', ({
      status,
      isSuitesSkipped,
      isSuitesSkippingEnabled,
      isCodeCoverageEnabled,
      testCodeCoverageLinesTotal,
      numSkippedSuites,
      hasUnskippableSuites,
      hasForcedToRunSuites,
      error,
      isEarlyFlakeDetectionEnabled,
      isEarlyFlakeDetectionFaulty,
      isTestManagementTestsEnabled,
      onDone
    }) => {
      this.testSessionSpan.setTag(TEST_STATUS, status)
      this.testModuleSpan.setTag(TEST_STATUS, status)

      if (error) {
        this.testSessionSpan.setTag('error', error)
        this.testModuleSpan.setTag('error', error)
      }

      addIntelligentTestRunnerSpanTags(
        this.testSessionSpan,
        this.testModuleSpan,
        {
          isSuitesSkipped,
          isSuitesSkippingEnabled,
          isCodeCoverageEnabled,
          testCodeCoverageLinesTotal,
          skippingType: 'suite',
          skippingCount: numSkippedSuites,
          hasUnskippableSuites,
          hasForcedToRunSuites
        }
      )

      if (isEarlyFlakeDetectionEnabled) {
        this.testSessionSpan.setTag(TEST_EARLY_FLAKE_ENABLED, 'true')
      }
      if (isEarlyFlakeDetectionFaulty) {
        this.testSessionSpan.setTag(TEST_EARLY_FLAKE_ABORT_REASON, 'faulty')
      }
      if (isTestManagementTestsEnabled) {
        this.testSessionSpan.setTag(TEST_MANAGEMENT_ENABLED, 'true')
      }

      this.testModuleSpan.finish()
      this.telemetry.ciVisEvent(TELEMETRY_EVENT_FINISHED, 'module')
      this.testSessionSpan.finish()
      this.telemetry.ciVisEvent(TELEMETRY_EVENT_FINISHED, 'session')
      finishAllTraceSpans(this.testSessionSpan)

      this.telemetry.count(TELEMETRY_TEST_SESSION, {
        provider: this.ciProviderName,
        autoInjected: !!getEnvironmentVariable('DD_CIVISIBILITY_AUTO_INSTRUMENTATION_PROVIDER')
      })

      this.tracer._exporter.flush(() => {
        if (onDone) {
          onDone()
        }
      })
    })

    // Test suites can be run in a different process from jest's main one.
    // This subscriber changes the configuration objects from jest to inject the trace id
    // of the test session to the processes that run the test suites, and other data.
    this.addSub('ci:jest:session:configuration', configs => {
      configs.forEach(config => {
        config._ddTestSessionId = this.testSessionSpan.context().toTraceId()
        config._ddTestModuleId = this.testModuleSpan.context().toSpanId()
        config._ddTestCommand = this.testSessionSpan.context()._tags[TEST_COMMAND]
        config._ddItrCorrelationId = this.itrCorrelationId
        config._ddIsEarlyFlakeDetectionEnabled = !!this.libraryConfig?.isEarlyFlakeDetectionEnabled
        config._ddEarlyFlakeDetectionNumRetries = this.libraryConfig?.earlyFlakeDetectionNumRetries ?? 0
        config._ddRepositoryRoot = this.repositoryRoot
        config._ddIsFlakyTestRetriesEnabled = this.libraryConfig?.isFlakyTestRetriesEnabled ?? false
        config._ddIsTestManagementTestsEnabled = this.libraryConfig?.isTestManagementEnabled ?? false
        config._ddTestManagementAttemptToFixRetries = this.libraryConfig?.testManagementAttemptToFixRetries ?? 0
        config._ddFlakyTestRetriesCount = this.libraryConfig?.flakyTestRetriesCount
        config._ddIsDiEnabled = this.libraryConfig?.isDiEnabled ?? false
        config._ddIsKnownTestsEnabled = this.libraryConfig?.isKnownTestsEnabled ?? false
        config._ddIsImpactedTestsEnabled = this.libraryConfig?.isImpactedTestsEnabled ?? false
      })
    })

    this.addSub('ci:jest:test-suite:start', ({
      testSuite,
      testSourceFile,
      testEnvironmentOptions,
      frameworkVersion,
      displayName,
      testSuiteAbsolutePath
    }) => {
      const {
        _ddTestSessionId: testSessionId,
        _ddTestCommand: testCommand,
        _ddTestModuleId: testModuleId,
        _ddItrCorrelationId: itrCorrelationId,
        _ddForcedToRun,
        _ddUnskippable,
        _ddTestCodeCoverageEnabled
      } = testEnvironmentOptions

      const testSessionSpanContext = this.tracer.extract('text_map', {
        'x-datadog-trace-id': testSessionId,
        'x-datadog-parent-id': testModuleId
      })

      const testSuiteMetadata = getTestSuiteCommonTags(testCommand, frameworkVersion, testSuite, 'jest')

      if (_ddUnskippable) {
        const unskippableSuites = this.getUnskippableSuites(_ddUnskippable)
        if (unskippableSuites[testSuite]) {
          this.telemetry.count(TELEMETRY_ITR_UNSKIPPABLE, { testLevel: 'suite' })
          testSuiteMetadata[TEST_ITR_UNSKIPPABLE] = 'true'
        }
        if (_ddForcedToRun) {
          const forcedToRunSuites = this.getForcedToRunSuites(_ddForcedToRun)
          if (forcedToRunSuites[testSuite]) {
            this.telemetry.count(TELEMETRY_ITR_FORCED_TO_RUN, { testLevel: 'suite' })
            testSuiteMetadata[TEST_ITR_FORCED_RUN] = 'true'
          }
        }
      }
      if (itrCorrelationId) {
        testSuiteMetadata[ITR_CORRELATION_ID] = itrCorrelationId
      }
      if (displayName) {
        testSuiteMetadata[JEST_DISPLAY_NAME] = displayName
      }
      if (testSourceFile) {
        testSuiteMetadata[TEST_SOURCE_FILE] = testSourceFile
        // Test suite is the whole test file, so we can use the first line as the start
        testSuiteMetadata[TEST_SOURCE_START] = 1
      }

      const codeOwners = this.getCodeOwners(testSuiteMetadata)
      if (codeOwners) {
        testSuiteMetadata[TEST_CODE_OWNERS] = codeOwners
      }

      this.testSuiteSpan = this.tracer.startSpan('jest.test_suite', {
        childOf: testSessionSpanContext,
        tags: {
          [COMPONENT]: this.constructor.id,
          ...this.testEnvironmentMetadata,
          ...testSuiteMetadata
        },
        integrationName: this.constructor.id
      })
      this.telemetry.ciVisEvent(TELEMETRY_EVENT_CREATED, 'suite')
      if (_ddTestCodeCoverageEnabled) {
        this.telemetry.ciVisEvent(TELEMETRY_CODE_COVERAGE_STARTED, 'suite', { library: 'istanbul' })
      }
      this.testSuiteSpanPerTestSuiteAbsolutePath.set(testSuiteAbsolutePath, this.testSuiteSpan)
    })

    this.addSub('ci:jest:worker-report:coverage', data => {
      const formattedCoverages = JSON.parse(data).map(coverage => ({
        sessionId: id(coverage.sessionId),
        suiteId: id(coverage.suiteId),
        files: coverage.files
      }))
      formattedCoverages.forEach(formattedCoverage => {
        this.tracer._exporter.exportCoverage(formattedCoverage)
      })
    })

    this.addSub('ci:jest:test-suite:finish', ({ status, errorMessage, error, testSuiteAbsolutePath }) => {
      const testSuiteSpan = this.testSuiteSpanPerTestSuiteAbsolutePath.get(testSuiteAbsolutePath)
      if (!testSuiteSpan) {
        log.warn('"ci:jest:test-suite:finish": no span found for test suite absolute path %s', testSuiteAbsolutePath)
        return
      }
      testSuiteSpan.setTag(TEST_STATUS, status)
      if (error) {
        testSuiteSpan.setTag('error', error)
        testSuiteSpan.setTag(TEST_STATUS, 'fail')
      } else if (errorMessage) {
        testSuiteSpan.setTag('error', new Error(errorMessage))
        testSuiteSpan.setTag(TEST_STATUS, 'fail')
      }
      // We need to give the potential error in 'ci:jest:test-suite:error' time to be published
      process.nextTick(() => {
        testSuiteSpan.finish()
        this.telemetry.ciVisEvent(TELEMETRY_EVENT_FINISHED, 'suite')
        // Suites potentially run in a different process than the session,
        // so calling finishAllTraceSpans on the session span is not enough
        finishAllTraceSpans(testSuiteSpan)
        // Flushing within jest workers is cheap, as it's just interprocess communication
        // We do not want to flush after every suite if jest is running tests serially,
        // as every flush is an HTTP request.
        if (isJestWorker) {
          this.tracer._exporter.flush()
        }
        this.removeAllDiProbes()
        this.testSuiteSpanPerTestSuiteAbsolutePath.delete(testSuiteAbsolutePath)
      })
    })

    this.addSub('ci:jest:test-suite:error', ({ error, errorMessage, testSuiteAbsolutePath }) => {
      const runningTestSuiteSpan = this.testSuiteSpanPerTestSuiteAbsolutePath.get(testSuiteAbsolutePath)
      if (!runningTestSuiteSpan) {
        log.warn('"ci:jest:test-suite:error": no span found for test suite absolute path %s', testSuiteAbsolutePath)
        return
      }
      if (error) {
        runningTestSuiteSpan.setTag('error', error)
      } else if (errorMessage) {
        runningTestSuiteSpan.setTag('error', new Error(errorMessage))
      }
    })

    /**
     * This can't use `this.libraryConfig` like `ci:mocha:test-suite:code-coverage`
     * because this subscription happens in a different process from the one
     * fetching the ITR config.
     */
    this.addSub('ci:jest:test-suite:code-coverage', ({ coverageFiles, testSuite, mockedFiles }) => {
      if (!coverageFiles.length) {
        this.telemetry.count(TELEMETRY_CODE_COVERAGE_EMPTY)
      }
      const files = [...coverageFiles, ...mockedFiles, testSuite]

      const { _traceId, _spanId } = this.testSuiteSpan.context()
      const formattedCoverage = {
        sessionId: _traceId,
        suiteId: _spanId,
        files
      }

      this.tracer._exporter.exportCoverage(formattedCoverage)
      this.telemetry.ciVisEvent(TELEMETRY_CODE_COVERAGE_FINISHED, 'suite', { library: 'istanbul' })
      this.telemetry.distribution(TELEMETRY_CODE_COVERAGE_NUM_FILES, {}, files.length)
    })

    this.addBind('ci:jest:test:start', (ctx) => {
      const store = storage('legacy').getStore()
      const span = this.startTestSpan(ctx)

      ctx.parentStore = store
      ctx.currentStore = { ...store, span }

      this.activeTestSpan = span

      return ctx.currentStore
    })

    this.addBind('ci:jest:test:fn', (ctx) => {
      return ctx.currentStore
    })

    this.addSub('ci:jest:test:finish', ({
      span,
      status,
      testStartLine,
      attemptToFixPassed,
      failedAllTests,
      attemptToFixFailed,
      isAtrRetry
    }) => {
      span.setTag(TEST_STATUS, status)
      if (testStartLine) {
        span.setTag(TEST_SOURCE_START, testStartLine)
      }
      if (attemptToFixPassed) {
        span.setTag(TEST_MANAGEMENT_ATTEMPT_TO_FIX_PASSED, 'true')
      } else if (attemptToFixFailed) {
        span.setTag(TEST_MANAGEMENT_ATTEMPT_TO_FIX_PASSED, 'false')
      }
      if (failedAllTests) {
        span.setTag(TEST_HAS_FAILED_ALL_RETRIES, 'true')
      }
      if (isAtrRetry) {
        span.setTag(TEST_IS_RETRY, 'true')
        span.setTag(TEST_RETRY_REASON, TEST_RETRY_REASON_TYPES.atr)
      }

      const spanTags = span.context()._tags
      this.telemetry.ciVisEvent(
        TELEMETRY_EVENT_FINISHED,
        'test',
        {
          hasCodeOwners: !!spanTags[TEST_CODE_OWNERS],
          isNew: spanTags[TEST_IS_NEW] === 'true',
          isRum: spanTags[TEST_IS_RUM_ACTIVE] === 'true',
          browserDriver: spanTags[TEST_BROWSER_DRIVER]
        }
      )

      span.finish()
      finishAllTraceSpans(span)
      this.activeTestSpan = null
    })

    this.addSub('ci:jest:test:err', ({ span, error, shouldSetProbe, promises }) => {
      if (error && span) {
        span.setTag(TEST_STATUS, 'fail')
        span.setTag('error', getFormattedError(error, this.repositoryRoot))
        if (shouldSetProbe) {
          const probeInformation = this.addDiProbe(error)
          if (probeInformation) {
            const { setProbePromise } = probeInformation
            promises.isProbeReady = withTimeout(setProbePromise, 2000)
          }
        }
      }
    })

    this.addSub('ci:jest:test:skip', ({
      test,
      isDisabled
    }) => {
      const span = this.startTestSpan(test)
      span.setTag(TEST_STATUS, 'skip')

      if (isDisabled) {
        span.setTag(TEST_MANAGEMENT_IS_DISABLED, 'true')
      }

      span.finish()
    })
  }

  startTestSpan (test) {
    const {
      suite,
      name,
      displayName,
      testParameters,
      frameworkVersion,
      testStartLine,
      testSourceFile,
      isNew,
      isEfdRetry,
      isAttemptToFix,
      isAttemptToFixRetry,
      isJestRetry,
      isDisabled,
      isQuarantined,
      isModified,
      testSuiteAbsolutePath
    } = test

    const extraTags = {
      [JEST_TEST_RUNNER]: 'jest-circus',
      [TEST_PARAMETERS]: testParameters,
      [TEST_FRAMEWORK_VERSION]: frameworkVersion
    }
    if (testStartLine) {
      extraTags[TEST_SOURCE_START] = testStartLine
    }
    // If for whatever we don't have the source file, we'll fall back to the suite name
    extraTags[TEST_SOURCE_FILE] = testSourceFile || suite

    if (displayName) {
      extraTags[JEST_DISPLAY_NAME] = displayName
    }

    if (isAttemptToFix) {
      extraTags[TEST_MANAGEMENT_IS_ATTEMPT_TO_FIX] = 'true'
    }

    if (isAttemptToFixRetry) {
      extraTags[TEST_IS_RETRY] = 'true'
      extraTags[TEST_RETRY_REASON] = TEST_RETRY_REASON_TYPES.atf
    } else if (isEfdRetry) {
      extraTags[TEST_IS_RETRY] = 'true'
      extraTags[TEST_RETRY_REASON] = TEST_RETRY_REASON_TYPES.efd
    } else if (isJestRetry) {
      extraTags[TEST_IS_RETRY] = 'true'
      extraTags[TEST_RETRY_REASON] = TEST_RETRY_REASON_TYPES.ext
    }

    if (isDisabled) {
      extraTags[TEST_MANAGEMENT_IS_DISABLED] = 'true'
    }

    if (isQuarantined) {
      extraTags[TEST_MANAGEMENT_IS_QUARANTINED] = 'true'
    }

    if (isModified) {
      extraTags[TEST_IS_MODIFIED] = 'true'
    }

    if (isNew) {
      extraTags[TEST_IS_NEW] = 'true'
    }
    const testSuiteSpan = this.testSuiteSpanPerTestSuiteAbsolutePath.get(testSuiteAbsolutePath) || this.testSuiteSpan

    return super.startTestSpan(name, suite, testSuiteSpan, extraTags)
  }
}

module.exports = JestPlugin
